Skip to content

fix(crypto)!: align NUT-29 batch quote signatures with amended spec#675

Open
robwoodgate wants to merge 19 commits into
mainfrom
fix/nut2029-msg-separators-main
Open

fix(crypto)!: align NUT-29 batch quote signatures with amended spec#675
robwoodgate wants to merge 19 commits into
mainfrom
fix/nut2029-msg-separators-main

Conversation

@robwoodgate

@robwoodgate robwoodgate commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Implements: cashubtc/nuts#375 to 91abdbb5

Summary

The current head of cashubtc/nuts#375 hardens the mint-quote signature message for both NUT-20 (single) and NUT-29 (batch) minting, harmonizing them on one domain-separated, length-framed construction:

msg_to_sign = DST || len32(quote) || quote
              || for each output: len32(amount) || amount || len32(B_) || B_

where DST = "Cashu_MintQuoteSig_v1", len32(x) is the 4-byte big-endian length of x, amount is the canonical minimal big-endian encoding (0 → empty), and B_ is the raw compressed point. The signature is a BIP340 Schnorr signature over the SHA-256 of that byte string.

Since the spec defines one message for both NUTs, all mint-quote signing lives in src/crypto/NUT20.ts (no separate NUT29 module):

  • signMintQuote / verifyMintQuoteSignature — the amended (spec) format, used for both single and batch minting against current mints. Pinned to the canonical vector in tests/29-tests.md (msg_hash = 03dc68d6…).
  • signMintQuoteLegacy / verifyMintQuoteSignatureLegacy — the pre-amendment concatenation (quote || B_0 || … || B_(n-1)), used internally as a transitional fallback for mints that predate the amendment. Not exported from the public barrel (the wallet imports them directly); the transition cleanup is pure deletion of the Legacy-suffixed pair.

Schnorr plumbing reuses crypto/core.ts (schnorrSignDigest plus a new schnorrVerifyDigest counterpart).

Note

Breaking (v5): signMintQuote/verifyMintQuoteSignature now produce/verify the amended message. The pre-amendment bytes are no longer exported — the Legacy pair remains only as an internal fallback driving the wallet's automatic legacy retry. Consumers needing the old bytes should pin v4.

Motivation

This aligns cashu-ts with the amended cashubtc/nuts#375 message construction. The message commits to the full ordered output set (each output's amount and blinded point), and length-frames every field so boundaries are unambiguous across curves (33-byte secp256k1 / 48-byte BLS points) without relying on point self-delimitation. See cashubtc/nuts#375 for the rationale.

Compatibility: legacy-signature fallback

Quotes are not versioned, so a wallet cannot tell from the quote itself which message format a mint verifies. Rather than sniff the mint's advertised version, the wallet signs the amended message by default and carries a legacy signature over the same outputs as a fallback:

  • prepareMint / prepareBatchMint sign both messages. The amended signature goes on the wire; the legacy one is kept in the preview (legacySignature / legacySignatures, marked @deprecated as transitional).
  • completeMint / completeBatchMint send the amended signature, and if the mint rejects it with error 20008 ("Signature for mint request invalid"), resend the same outputs with the legacy signature.

Redeeming a mint quote burns no inputs and is idempotent on the quote, so the extra attempt is safe. The retry is gated on the spec-defined 20008 code — verified consistent across the mints cashu-ts targets — so unrelated failures (unpaid, expired) still fail fast. On the mint endpoint a 20008 is unambiguously the quote signature, since a mint request carries no NUT-11 input witnesses.

The mint-quote signature message concatenated the quote id and blinded
messages with no delimiter (quote || B_0 || ... || B_(n-1)). Because the
B_ hex length varies by curve (66 chars for secp256k1, 96 for
BLS12-381), the field boundaries are ambiguous: distinct (quote, outputs)
tuples can serialize to the same signed bytes, so a signature is not
unambiguously bound to a single request.

Insert ":" (UTF-8 0x3A) between the quote id and each blinded message so
msg_to_sign = quote || ":" || B_0 || ":" || ... || ":" || B_(n-1). This
covers both NUT-20 single mint-quote and NUT-29 batch-mint signatures,
which share the construction. Mints must adopt the same separator to
verify.

Test vectors updated to the canonical ones in cashubtc/nuts#375.
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.30%. Comparing base (80a55b9) to head (57203e6).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #675      +/-   ##
==========================================
+ Coverage   95.20%   95.30%   +0.09%     
==========================================
  Files          54       54              
  Lines        5068     5108      +40     
  Branches     1253     1262       +9     
==========================================
+ Hits         4825     4868      +43     
+ Misses        103      102       -1     
+ Partials      140      138       -2     
Flag Coverage Δ
integration 38.60% <65.51%> (+0.26%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robwoodgate robwoodgate added the nut hold This PR is held pending the merge of a new or updated NUT label May 23, 2026
Revert NUT-20 to its retro-compatible message; NUT-29 batch minting now uses its
own domain-separated, length-framed message committing to each output's amount and
point (cashubtc/nuts#375).
@robwoodgate robwoodgate changed the title fix(crypto): add colon separators to mint-quote signature message fix(crypto): align NUT-29 batch quote signatures with amended spec May 26, 2026
Harden the public signBatchMintQuote/verifyBatchMintQuoteSignature against raw
JSON number/string amounts; Amount instances pass through unchanged.
Quotes are not versioned, so prepareBatchMint picks the signature format from
the mint's advertised implementation/version: Nutshell and cdk-mintd below the
first release that verifies the amended message (cashubtc/nuts#375) get the
legacy NUT-20-style message; everything else gets the amended format. Threshold
versions are placeholders until the upstream releases are known.

Adds MintInfo.isImplementationBelow() to parse and compare the NUT-06 version
string.
The current head of cashubtc/nuts#375 hardens the NUT-20 message itself, so the
single-quote path now picks its signature format the same way the batch path
does. The version threshold table and gate move to wallet/mintCompat.ts as
requiresLegacyQuoteSignature(), one place to collect (and later delete)
version-gated shims. NUT20/NUT29 modules document the legacy/amended split.
cashubtc/nuts#375 defines one mint-quote message for NUT-20 single and NUT-29
batch minting, so the NUT29 module dissolves into NUT20.ts: signMintQuote /
verifyMintQuoteSignature now produce the amended format, and the pre-amendment
concatenation moves to signMintQuoteLegacy / verifyMintQuoteSignatureLegacy.
The transition cleanup is then pure deletion of the Legacy-suffixed pair.
Integration against cdk-mintd/0.16.0 confirmed the latest releases verify only
the legacy message, so at-current thresholds break locked-quote minting. Next
minors (nutshell 0.21.0, cdk-mintd 0.17.0) keep every deployable mint on the
legacy path until the amended releases are pinned.
@robwoodgate robwoodgate changed the title fix(crypto): align NUT-29 batch quote signatures with amended spec fix(crypto)!: align NUT-29 batch quote signatures with amended spec Jun 12, 2026
Canonical amount encoding (zero and even-length hex), non-compressed pubkey
rejection in both verify functions, schnorrVerifyDigest hex-string digest and
throws paths, and version-segment zero-padding in isImplementationBelow.
A prerelease compares equal to its base version, so an rc of the threshold
release gets the amended format — during the rc window those are the only
deployments that verify it. Pin both rc styles (semver dash, PEP440) in tests.
Add the canonical msg_to_sign hash and signature vector from
tests/20-test.md alongside the already-pinned NUT-29 batch vector.
constructMessage/constructLegacyMessage are evaluated as arguments,
outside schnorrVerifyDigest's try/catch, so a negative amount or bad-hex
B_ threw past the boolean contract. Wrap both verify paths.
robwoodgate and others added 6 commits June 23, 2026 23:38
parseVersionSegments returned null on any non-numeric dot segment (e.g.
0.20.5.dev3, 0.16.5.post1), so an old mint with such a version was treated
as current and signed with the amended quote-sig format, which it rejects.
Read leading numeric segments and stop at the first prerelease/build tag so
the version gates to legacy.
…allback

Sign the amended (nuts#375) mint-quote message by default and retry with a legacy
signature when a pre-amendment mint rejects it with error 20008. Removes the version-
sniffing compat shim (mintCompat, MintInfo.isImplementationBelow), so releasing no
longer depends on pinning the first mint versions that verify the amended message.
Log a warning before retrying a mint request with the legacy NUT-20 signature, so
operators can see when they are talking to a mint that predates the amended message.
Drop signMintQuoteLegacy/verifyMintQuoteSignatureLegacy from the public crypto barrel
(switch the NUT20 re-export to the amended pair only). The legacy message is a
transitional fallback used internally by Wallet.completeMint and deleted with it, so
v5 should not expose it as a fresh public symbol — mirroring how v4 keeps its amended
pair internal. Wallet and tests import the legacy pair directly from crypto/NUT20.

Documents the amended-message switch in migration-5.0.0.md.
@robwoodgate robwoodgate added the experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental label Jul 1, 2026
…retry

Add an isMintOperationError type guard (exported) that also matches
look-alike errors by name + code, so a custom request layer or a
duplicate package copy does not silently disable the legacy signature
fallback. The 20008 retry now uses the guard instead of instanceof.

Document the RequestFn error contract: a customRequest must surface
mint protocol errors as errors the guard accepts, preferably this
package's MintOperationError, and consumers who only need a different
transport should prefer requestFetch, which keeps the contract for
them.
# Conflicts:
#	test/crypto/NUT20.test.ts
#	test/crypto/NUT29.test.ts
#	test/model/Errors.test.ts
#	test/wallet/wallet-mint.node.test.ts
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (incremental)

✅ Mutation score: 89.5% (break threshold: 80%) — repo-wide 5269 killed / 537 survived / 80 no-coverage.

Surviving mutants in files this PR changed (267):

  • crypto/core.ts:194 BlockStatement: {}
  • crypto/core.ts:196 ConditionalExpression: true
  • crypto/core.ts:196 ConditionalExpression: false
  • crypto/core.ts:196 EqualityOperator: validSigners.length > threshold
  • crypto/core.ts:196 EqualityOperator: validSigners.length < threshold
  • mint/Mint.ts:214 OptionalChaining: data.signatures
  • mint/Mint.ts:857 OptionalChaining: data.states
  • mint/Mint.ts:952 OptionalChaining: data.outputs
  • mint/Mint.ts:952 OptionalChaining: data.signatures
  • mint/Mint.ts:988 OptionalChaining: this.ws.close
  • mint/Mint.ts:1028 LogicalOperator: mintInfo && (await this.getLazyMintInfo())
  • mint/Mint.ts:1050 LogicalOperator: mintInfo && (await this.getLazyMintInfo())
  • mint/Mint.ts:471 OptionalChaining: data.signatures
  • mint/Mint.ts:546 OptionalChaining: data.signatures
  • mint/Mint.ts:1074 LogicalOperator: init.headers && {}
  • mint/Mint.ts:1252 ConditionalExpression: false
  • model/Errors.ts:87 ConditionalExpression: true
  • wallet/Wallet.ts:103 StringLiteral: ""
  • wallet/Wallet.ts:319 ObjectLiteral: {}
  • wallet/Wallet.ts:320 ObjectLiteral: {}
  • wallet/Wallet.ts:394 ObjectLiteral: {}
  • wallet/Wallet.ts:521 ObjectLiteral: {}
  • wallet/Wallet.ts:535 ObjectLiteral: {}
  • wallet/Wallet.ts:541 ArrayDeclaration: ["Stryker was here"]
  • wallet/Wallet.ts:551 OptionalChaining: ot.denominations.length
  • wallet/Wallet.ts:563 ObjectLiteral: {}
  • wallet/Wallet.ts:565 ArithmeticOperator: i + 1
  • wallet/Wallet.ts:579 StringLiteral: ""
  • wallet/Wallet.ts:579 ObjectLiteral: {}
  • wallet/Wallet.ts:633 ObjectLiteral: {}

Full report (all files): download the mutation-report-pr artifact from this run. Codecov shows lines this PR left uncovered; this shows changed lines that run but aren’t asserted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental nut hold This PR is held pending the merge of a new or updated NUT

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant